iT邦幫忙

2024 iThome 鐵人賽

DAY 9
0
Software Development

用 NestJS 闖蕩微服務!系列 第 9

[用NestJS闖蕩微服務!] DAY09 - gRPC Transporter (上)

  • 分享至 

  • xImage
  •  

什麼是 gRPC?

gRPC 是一套高效、跨平台、基於 HTTP/2 的 遠端程序呼叫(Remote Procedure Call, RPC) 框架,有低延遲、串流等特色,近年來被廣泛用於微服務間的通訊。

補充:gRPC 最早是由 Google 所創造,在 2015 年正式開源,現已納入 CNCF 孵化項目。

gRPC Logo

圖片來源

RPC

gRPC 是一套 RPC 框架,我們需要先了解一下 RPC 是什麼東西,簡單來說,RPC 就是讓 A 設備上的程式可以呼叫 B 設備提供的函式,在撰寫程式時,感覺就像是呼叫自己程式提供的函式,無需關注通訊的方式。

RPC 的設計下,在 Client 端與 Server 端都會存在一個叫 樁(Stub) 的角色,這些 Stub 會依照定義好的介面來實作,Client Stub 負責提供函式讓 Client 應用程式可以執行,執行後由 Client Stub 處理通訊與資料打包的工作,Server Stub 則負責解析並呼叫實際存在的函式,再以類似 Client Stub 的處理方式進行回應。

gRPC 也是遵循這個概念進行實作,在 Client 端會有 gRPC Stub 來提供函式、處理通訊與資料打包,gRPC Server 負責解析、呼叫實際存在的函式並進行回應。

gRPC Concept

Protocol Buffer

gRPC 預設使用 Protocol Buffer 當作 介面描述語言(Interface Definition Language, IDL),那什麼是 Protocol Buffer 呢?它是用來定義介面、序列化格式的解決方案,能夠跨語言使用,提供比 JSON 更輕量的二進制資料,也因為資料是二進制的,所以大幅提升了資料的隱匿性。gRPC 基於 Protocol Buffer 來定義服務的介面與資料的格式,不僅實現了介面的定義,還利用 Protocol Buffer 的特性來降低傳輸的延遲並提升資料的隱匿性,真是一舉數得。

補充:Protocol Buffer 同樣由 Google 開源。

注意:Protocol Buffer 目前有兩種版本,分別是 proto2proto3,gRPC 官方建議採用的版本為 proto3,故後續都會以 proto3 來進行說明。

Protocol Buffer 本身有自己的副檔名 - .proto,檔案名稱要使用 小寫蛇形的方式命名,如:lower_snake_case.proto,如果要採用 proto3 的話,會以關鍵字 syntax 來宣告並放在檔案的第一行。下方是範例程式碼:

syntax = "proto3";

定義訊息

在 Protocol Buffer 的定義,每一筆資料都是由 訊息(Message) 組成,用 TypeScript 的角度來說的話,就像是一個 interface。下方是一個用 TypeScript 定義的 User 介面:

interface User {
  first_name: string;
  middle_name: string;
  last_name: string;
  age: number;
}

用 Protocol Buffer 來表示的話,則會使用 message 關鍵字,並在每個欄位的開頭宣告型別,而每個欄位都必須賦予序號:

syntax = "proto3";

message User {
  string first_name = 1;
  string middle_name = 2;
  string last_name = 3;
  int32 age = 4;
}

除了上面使用的型別以外,Message 也可以是其他 Message 的型別:

syntax = "proto3";

message User {
  string first_name = 1;
  string middle_name = 2;
  string last_name = 3;
  int32 age = 4;
}

message Result {
  User user = 1;
}

注意:根據 Protocol Buffer 官方的 Style Guide,定義 message 的名稱要使用 帕斯卡命名(PascalCase),如:CreateUser,而欄位名稱要使用 小寫蛇形命名,如:first_name

補充:剛接觸 Protocol Buffer 可能會覺得很奇怪,為什麼要賦予序號?這部份稍後會進行說明。

Scalar 型別

Protocol Buffer 針對欄位定義的型別採用的是 Scalar Value Types,下方是提供的型別關鍵字:

  • bool:布林值。
  • string:字串,必須採用 UTF-8 或 7-bit ASCII 編碼,且長度不得超過 2^32
  • bytes:可以包含任意位元組的資料,但不得超過 2^32
  • double:雙精度浮點數。
  • float:單精度浮點數。
  • int3232 位元帶正負號的整數。
  • int6464 位元帶正負號的整數。
  • uint3232 位元不帶正負號的整數。
  • uint6464 位元不帶正負號的整數。
  • sint32:與 int32 相似,但較有效地處理負數。
  • sint64:與 int64 相似,但較有效地處理負數。
  • fixed32: 固定 32 位元不帶正負號的整數,如果數字經常大於 2^28 建議使用,效率較佳。
  • fixed64:固定 64 位元不帶正負號的整數,如果數字經常大於 2^56 建議使用,效率較佳。
  • sfixed32:固定 32 位元帶正負號的整數。
  • sfixed64:固定 64 位元帶正負號的整數。

注意:這些定義在轉換成對應語言時會有不同的型別,以 Golang 來說,double 對應的會是 double64,詳細資訊可以參考官方文件

列舉

Protocol Buffer 有支援 列舉(Enum),使用關鍵字 enum 進行定義。下方是範例程式碼:

syntax = "proto3";

enum Gender {
  MALE = 0;
  FEMALE = 1;
  OTHERS = 2;
}

在使用上有些規則需要注意:

  • Enum 的起始值必須為 0
  • Enum 的範圍要限制在 32 位元整數內。
  • Enum 不建議使用負數。

注意:根據 Protocol Buffer 官方的 Style Guide,Enum 的名稱要使用 PascalCase,如:StatusCode,而欄位名稱要使用 大寫蛇形命名,如:NOT_FOUND

欄位序號

前面有提到在 Message 裡面的每個欄位都必須賦予序號,這個序號是讓 Protocol Buffer 在二進制狀態下識別欄位的識別碼,可定義的區間為 1536,870,911,但有些限制需要注意:

  • 在一個 Message 中,每個欄位都有獨一無二的序號,原因是如果出現重複序號,對 Protocol Buffer 來說就無法辨別該序號的值是對應到哪個欄位。
  • 19,00019,999 不得使用,這個區間是 Protocol Buffer 實作用的。
  • 一旦序號被使用就 無法修改,因為傳輸中的資料格式是依據該序號進行編碼的,這可能會導致錯誤發生,如:解析錯誤、資料損壞等。
  • 修改欄位序號相當於刪除欄位並新增一個與刪除欄位相同型別的欄位。
  • 如果有欄位要棄用,不建議直接刪除欄位,而是要將欄位標示為 保留(Reserved),避免未來誤用。
  • 為了減少編碼後的大小,建議將最常用的欄位設置在 115 之間,細節可以參考官方文件

保留欄位需要在 Message 裡面使用 reserved 關鍵字來標示哪些欄位、欄位名稱被保留。下方是範例程式碼,將 10122026 標示為保留,同時也將欄位名稱 job 標示為保留:

syntax = "proto3";

message User {
  reserved 10, 12, 20 to 26;
  reserved "job";
  string first_name = 1;
  string middle_name = 2;
  string last_name = 3;
  int32 age = 4;
}

注意:以上幾點限制 非常重要,尤其是針對修改欄位序號與刪除欄位的操作,除非可以 100% 確定修改後的 proto 會同時在所有微服務應用程式中進行更新,且不會有任何一個用舊 proto 編碼的資料存在,但這種可能性極低,所以應避免。

補充:如果對 Protocol Buffer 是如何編碼有興趣的話,可以參考官方的說明文件

特定欄位標籤

Protocol Buffer 設計了特定欄位標籤來針對欄位做特殊處理,比如:是否為非必要的、是否會有重複欄位的訊息等。

Optional

如果有一個欄位不是必填,那可以將該欄位用關鍵字 optional 標示。下方為範例程式碼:

syntax = "proto3";

message User {
  string first_name = 1;
  optional string middle_name = 2;
  string last_name = 3;
  int32 age = 4;
}

當標示為 optional 的欄位沒有被設置時,它不會被編碼並傳輸,但資料解析時將會以 預設值(Default Value) 表示,下方列出各個型別下的預設值:

  • string:預設為 空字串
  • bytes:預設為 空 Byte
  • bool:預設為 false
  • 數字型別:預設為 0
  • enum:預設為 第一個列舉數值,也就是 0
  • message:預設值取決於使用的語言,可以參考官方文件
Repeated

如果有一個欄位會出現 0 次或數次,可以將該欄位用關鍵字 repeated 標示,用比較簡單的說法即陣列型別。下方為範例程式碼:

syntax = "proto3";

message User {
  string first_name = 1;
  string middle_name = 2;
  string last_name = 3;
  int32 age = 4;
  repeated string tags = 5;
}

注意:根據 Protocol Buffer 官方的 Style Guide,定義 repeated 的欄位名稱要使用 複數型態,如:tags

Map

如果有一個欄位用於建立映射關係,可以將該欄位用關鍵字 map 標示。map 的定義方式如下:

map<key_type, value_type> map_field = N;

key_type 可以是 integerstring,而 value_type 可以是除了 map 以外的任何型別。

下方是範例程式碼:

syntax = "proto3";

message User {
  string first_name = 1;
  string middle_name = 2;
  string last_name = 3;
  int32 age = 4;
  map<string, string> relationship = 5;
}

Map 在使用上需要特別注意下列幾個事項:

  • 不能使用 repeated 關鍵字。
  • 從傳輸的資料解析的 Map 排序並未定義,不建議依賴 Map 的欄位順序做迭代處理。
  • 如果從傳輸的資料解析到重覆 key_type,會以最後一個為主;但如果是從文字格式解析到重覆 key_type 則可能會失敗。

巢狀型別

Protocol Buffer 提供 巢狀型別(Nested Type),透過這種方式可以更好地歸納、分類 Message。下方是範例程式碼:

syntax = "proto3";

message User {
  message Name {
    string first_name = 1;
    optional string middle_name = 2;
    string last_name = 3;
  }

  Name name = 1;
  int32 age = 2;
}

Nested Type 可以被重用,甚至在閱讀上會更加值觀。下方是範例程式碼:

syntax = "proto3";

message User {
  message Name {
    string first_name = 1;
    optional string middle_name = 2;
    string last_name = 3;
  }

  Name name = 1;
  int32 age = 2;
}

message OtherMessage {
  User.Name name = 1;
}

模組化

實務上我們不太可能把所有的定義放在同一個 .proto 裡,會把不同類型的定義放在一起,並拆分成多個檔案,Protocol Buffer 提供了 import 關鍵字讓我們能引入其他 .proto 的定義。下方規劃了兩個 .proto 檔,分別是 user.protouser_result.proto

.
└─ user
   ├─ user.proto
   └─ user_result.proto

user.proto 定義了 User

syntax = "proto3";

message User {
  string first_name = 1;
  string middle_name = 2;
  string last_name = 3;
  int32 age = 4;
}

user_result.proto 定義了 Result,並引入 user.proto,進而使用 User

syntax = "proto3";

import "user.proto";

message Result {
  User user = 1;
}

如果希望避免 .proto 之間的 Message 名稱發生衝突,可以將 .proto 的內容進行 打包(Package),透過 package 關鍵字來定義該 Package 名稱。下方為範例程式碼,將 user.proto 定義 Package 名稱為 user.definition

syntax = "proto3";

package user.definition;

message User {
  string first_name = 1;
  string middle_name = 2;
  string last_name = 3;
  int32 age = 4;
}

接著,調整 user_result.proto 的內容,要將 User 換成以 Package 的方式取得 User

syntax = "proto3";

import "user.proto";

message Result {
  user.definition.User user = 1;
}

Protocol Buffer Compiler

撰寫完的 Protocol Buffer 定義可以透過 Protocol Buffer Compiler 產生特定語言的必要程式碼,像是:Golang、Java、Python 等。

安裝 Protocol Buffer Compiler

最簡單的安裝方式是從 GitHub Release 下載需要的版本來使用,如果是 Mac 的開發者則可以使用 Homebrew 進行安裝:

$ brew install protobuf

補充:從 GitHub Release 安裝可以參考這篇文章

安裝完之後,可以透過下方指令查詢 Protocol Buffer Compiler 版本:

$ protoc --version

Protocol Buffer Compiler Version

編譯 Protocol Buffer

要執行 Protocol Buffer Compiler 的話,需要先了解一些基本的參數設置,下方列出了最重要的兩個參數:

  • --proto_path:用來指定 .proto 的根目錄,如果不指定則會以下只零食的目錄位置作為根目錄,另外,也可以用縮寫 -I 來代替 --proto_path
  • --<XXX>_out:用來指定要編譯的程式語言程式碼以及其輸出位置,<XXX> 即程式碼代號。

這裡整理了部份程式語言的參數名稱以及對應的來源供參考:

  • --cpp_out:輸出 C++ 程式碼,細節可參考 官方文件
  • --java_out: 輸出 Java 程式碼,細節可參考 官方文件
  • --python_out:輸出 Python 程式碼,細節可參考 官方文件
  • --go_out:輸出 Golang 程式碼,細節可參考 官方文件
  • --ruby_out:輸出 Ruby 程式碼,細節可參考 官方文件
  • --objc_out:輸出 Objective-C 程式碼,細節可參考 官方文件
  • --csharp_out:輸出 C# 程式碼,細節可參考 官方文件
  • --php_out:輸出 PHP 程式碼,細節可參考 官方文件

補充:為什麼沒有看到 JavaScript 或 TypeScript 的相關參數呢?事實上官方沒有將它們列在文件裡,但其實是有提供 --js_out 的,只是在使用上與其他程式語言較不同,且需要額外安裝 protoc-gen-js,故在此就不特別針對 JavaScript 的部份進行編譯,至於 TypeScript 的部份,我們會使用其他套件來輔助編譯,這部分留到後面再進一步說明。

下方是編譯的指令,這裡我們針對 user_result.proto 進行編譯,並將其編譯成 Python 程式碼,<IMPORT_PATH> 輸入 .proto 的根目錄、<DST_DIR> 輸入要輸出的檔案路徑:

$ protoc --proto_path=<IMPORT_PATH> --python_out=<DST_DIR> user_result.proto

注意:如果使用相對路徑的話,需注意當前終端機所在位置。

補充:Protocol Buffer Compiler 支援一次輸出多種程式語言的程式碼,只需要輸入好參數即可,以輸出 Python 與 Java 為例,只需要帶入 --python_out=<DST_DIR>--java_out=<DST_DIR> 即可。

執行完畢後,就會看到指定的輸出路徑出現了 Python 的檔案:

Protocol Buffer Compiler For Python

定義服務

在 RPC 的設計下,經常會需要為服務定義介面,以利產生相對應的 Stub 與介面,gRPC 也遵循著這個設計方式,那要如何替服務定義介面呢?事實上,Protocol Buffer 有提供 service 關鍵字來宣告型別。下方是範例程式碼:

syntax = "proto3";

message User {
  string name = 1;
  int32 age = 2;
}

message GetUserRequest {
  string name = 1;
}

message GetUserResponse {
  User user = 1;
}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

可以看到定義了三個 Message,分別是 UserGetUserRequestGetUserResponse,同時定義了 UserService 這個服務介面,它使用 rpc 關鍵字提供了 GetUser 函式,輸入值的介面即 GetUserRequest,回傳值的部份使用 returns 表示,這裡回傳值的介面即 GetUserResponse

注意:根據 Protocol Buffer 官方的 Style Guide,定義 service 的名稱要使用 PascalCase,如:UserService,其提供的函式也要使用 PascalCase,如:GetUser

補充:關於 RPC 函式設計的最佳實踐可以參考官方文件

針對服務介面的設計,可能會因為 RPC 的生命週期而有不同的撰寫方式,gRPC 提供了下列四種 RPC 設計:

Unary RPC

Unary RPC 是最簡單的 RPC 類型,就是我們最熟悉的 Request-response 模式,一個 Request 對應一個 Response。

寫法上與上方的範例相同:

syntax = "proto3";

// ...

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

Server Streaming RPC

與 Unary RPC 相似,但是由 Client 端發起一個 Request,Server 端會以 串流 的形式持續回應資料。下方是範例程式碼,可以看到在 returns 的地方使用了 stream 關鍵字:

syntax = "proto3";

// ...

service LogService {
  rpc GetLogs(GetLogsRequest) returns (stream GetLogResponse);
}

Client Streaming RPC

與 Server Streaming RPC 相反,是由 Client 發起串流的 Request,將資料持續發送給 Server 端,Server 端在接收、處理完資料後,由一個 Response 進行回應。下方是範例程式碼,可以看到 stream 是加在函式的參數前面:

syntax = "proto3";

// ...

service FileService {
  rpc UploadFiles(stream UploadFileRequest) returns (UploadFilesResponse);
}

Bidirectional Streaming RPC

這是雙向串流的 RPC 類型,Client 會發起串流的 Request,將資料持續發送給 Server 端,Server 端也以串流的形式持續回應資料。下方是範例程式碼,可以看到 stream 同時加在函式的參數前面以及 returns 的地方:

syntax = "proto3";

// ...

service FileService {
  rpc UploadFiles(stream UploadFileRequest) returns (stream UploadFileResponse);
}

Metadata

Metadata 是用於特定 RPC 的資訊,像是:驗證資訊等,會以 key value pairs 的形式存在,在使用上需要特別注意以下幾點:

  • Key 的型別一定是字串,由 ACSII 字母、-_. 組成,並且 不能grpc- 開頭。
  • Value 的型別通常是字串,但也可以是二進制資料。
  • 如果 Value 是二進制資料,Key 需要以 -bin 作為後綴。

補充:存取 Metadata 的方式取決於使用的程式語言,這部份下一篇章會進一步說明。

小結

回顧一下本篇的重點內容,一開始先針對 gRPC 做了基本的介紹,包含:gRPC 是什麼?RPC 如何運作?Protocol Buffer 跟 gRPC 的關係等。
接著,開始介紹 Protocol Buffer 要如何撰寫,包含:Message 的定義、資料型別有哪些、Service 要如何定義等。
最後再回頭講解 gRPC 提供的四種 RPC 類型,分別是:Unary RPC、Server Streaming RPC、Client Streaming RPC 與 Bidirectional Streaming RPC。

由於 gRPC 的介紹很豐富,故拆分成上下兩篇來完成一系列的 gRPC Transporter 介紹,在閱讀完本篇內容後,應該就具備了基礎的 gRPC 知識,下一篇會正式進入 NestJS gRPC Transporter 的介紹,敬請期待!


上一篇
[用NestJS闖蕩微服務!] DAY08 - Kafka Transporter
下一篇
[用NestJS闖蕩微服務!] DAY10 - gRPC Transporter (下)
系列文
用 NestJS 闖蕩微服務!21
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言